Passed
Pull Request — main (#22)
by Alberto
02:10
created

BEditaApiClient.post   A

Complexity

Conditions 1

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 8
dl 0
loc 15
rs 10
c 0
b 0
f 0
cc 1
1
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse }  from 'axios';
2
import AuthInterceptor from './interceptors/auth-interceptor';
3
import RefreshAuthInterceptor from './interceptors/refresh-auth-interceptor';
4
import StorageService from './services/storage-service';
5
import FormatUserInterceptor from './interceptors/format-user.interceptor';
6
import ContentTypeInterceptor from './interceptors/content-type-interceptor';
7
import { RequestInterceptorInterface } from './interceptors/request-interceptor';
8
import { ResponseInterceptorInterface } from './interceptors/response-interceptor';
9
import MapIncludedInterceptor from './interceptors/map-included-interceptor';
10
11
/**
12
 * Interface for API client configuration.
13
 *
14
 * - baseUrl: the BEdita API base URL
15
 * - apiKey: the API KEY to use (optional). Deprecated, you are encouraged to use `clientId` and `clientSecret` instead.
16
 * - name: the name of the client instance (optional, default 'bedita')
17
 * - clientId: the client id used for client credentials flow (optional)
18
 * - clientSecret: the client secret used for client credentials flow (optional)
19
 */
20
export interface ApiClientConfig {
21
    baseUrl: string,
22
    apiKey?: string,
23
    name?: string,
24
    clientId?: string,
25
    clientSecret?: string,
26
}
27
28
/**
29
 * Interface of JSON API resource object
30
 *
31
 * see https://jsonapi.org/format/#document-resource-objects
32
 */
33
export interface JsonApiResourceObject {
34
    type: string,
35
    id?: string,
36
    attributes?: { [s: string]: any },
37
    relationships?:  { [s: string]: any },
38
    links?: { [s: string]: any },
39
    meta?:  { [s: string]: any },
40
}
41
42
/**
43
 * Interface for a successfully API response body.
44
 */
45
export interface ApiResponseBodyOk {
46
    data: JsonApiResourceObject | JsonApiResourceObject[],
47
    meta: { [s: string]: any },
48
    links?: { [s: string]: any },
49
    included?: JsonApiResourceObject[],
50
}
51
52
/**
53
 * Interface for a errored API response body.
54
 */
55
export interface ApiResponseBodyError {
56
    error: { [s: string]: any },
57
    links?: { [s: string]: any },
58
    meta?: { [s: string]: any },
59
}
60
61
/**
62
 * Interface for configuration used for BEdita API requests.
63
 * Extends AxiosRequestConfig adding configuration for
64
 * dynamic uses of request and response interceptors.
65
 */
66
export interface BEditaClientRequestConfig extends AxiosRequestConfig {
67
    requestInterceptors?: RequestInterceptorInterface[],
68
    responseInterceptors?: ResponseInterceptorInterface[],
69
}
70
71
/**
72
 * Interface of BEdita client response.
73
 * It extends AxiosResponse adding an optional `formatData`
74
 * that can be used to store fromatted data.
75
 */
76
export interface BEditaClientResponse<T = any> extends AxiosResponse {
77
    formattedData?: T;
78
}
79
80
/**
81
 * String enums for grant types.
82
 */
83
export enum GrantType {
84
    Password = 'password',
85
    ClientCredentials = 'client_credentials',
86
    RefreshToken = 'refresh_token',
87
}
88
89
/**
90
 * Interface describing data used for auth action.
91
 */
92
export interface AuthData {
93
    username?: string,
94
    password?: string,
95
    client_id?: string,
96
    client_secret?: string,
97
    [s: string]: any,
98
    grant_type: GrantType | string,
99
}
100
101
/**
102
 * BEdita API client.
103
 */
104
export class BEditaApiClient {
105
106
    /**
107
     * The Api client configuration.
108
     */
109
    #config: ApiClientConfig;
110
111
    /**
112
     * Keep The axios instance.
113
     */
114
    #axiosInstance: AxiosInstance;
115
116
    /**
117
     * Keep the token service instance.
118
     */
119
    #storageService: StorageService;
120
121
    /**
122
     * Map of request interceptors added to avoid double addition.
123
     *
124
     * The values are the interceptor contructor names
125
     * and the keys are the corresponding index in Axios.
126
     */
127
    #requestInterceptorsMap: Map<string, number> = new Map();
128
129
    /**
130
     * Map of response interceptors added to avoid double addition.
131
     *
132
     * The values are the interceptor contructor names
133
     * and the keys are the corresponding index in Axios.
134
     */
135
    #responseInterceptorsMap: Map<string, number> = new Map();
136
137
    /**
138
     * Constructor.
139
     *
140
     * @param config The configuration for the API client
141
     */
142
    constructor(config: ApiClientConfig) {
143
        if (!config.name) {
144
            config.name = 'bedita';
145
        }
146
147
        const axiosConfig: AxiosRequestConfig = {
148
            baseURL: config.baseUrl,
149
            headers: {
150
                Accept: 'application/vnd.api+json',
151
            },
152
        };
153
154
        if (config.clientId) {
155
            delete config.apiKey; // remove deprecated API key
156
        }
157
158
        if (config.apiKey) {
159
            axiosConfig.headers['X-Api-Key'] = config.apiKey;
160
        }
161
162
        this.#config = { ...config };
163
        this.#axiosInstance = axios.create(axiosConfig);
164
        this.#storageService = new StorageService(config.name);
165
166
        this.addDefaultInterceptors();
167
    }
168
169
    /**
170
     * Return the client configuration.
171
     * If key is specified return only the value related.
172
     */
173
    public getConfig(key?: string): ApiClientConfig | any {
174
        if (key) {
175
            return this.#config?.[key] || null;
176
        }
177
178
        return this.#config;
179
    }
180
181
    /**
182
     * Add default interceptors.
183
     */
184
    protected addDefaultInterceptors(): void {
185
        this.addInterceptor(new AuthInterceptor(this));
186
        this.addInterceptor(new ContentTypeInterceptor(this));
187
        this.addInterceptor(new RefreshAuthInterceptor(this));
188
    }
189
190
    /**
191
     * Add an interceptor to the axios instance.
192
     *
193
     * @param interceptor The interceptor instance
194
     */
195
    public addInterceptor(interceptor: RequestInterceptorInterface | ResponseInterceptorInterface): number {
196
        const name = interceptor.constructor.name;
197
        if ('requestHandler' in interceptor) {
198
            if (this.#requestInterceptorsMap.has(name)) {
199
                return this.#requestInterceptorsMap.get(name);
200
            }
201
202
            const idx = this.#axiosInstance.interceptors.request.use(
203
                interceptor.requestHandler.bind(interceptor),
204
                interceptor.errorHandler.bind(interceptor)
205
            );
206
            this.#requestInterceptorsMap.set(name, idx);
207
208
            return idx;
209
        }
210
211
        if (this.#responseInterceptorsMap.has(name)) {
212
            return this.#responseInterceptorsMap.get(name);
213
        }
214
215
        const index = this.#axiosInstance.interceptors.response.use(
216
            interceptor.responseHandler.bind(interceptor),
217
            interceptor.errorHandler.bind(interceptor)
218
        );
219
        this.#responseInterceptorsMap.set(name, index);
220
221
        return index;
222
    }
223
224
    /**
225
     * Remove an interceptor from axios instance.
226
     *
227
     * @param id The interceptor id
228
     * @param type The interceptor type
229
     */
230
    public removeInterceptor(id: number, type: 'request' | 'response'): void {
231
        if (type === 'request') {
232
            for (const item of this.#requestInterceptorsMap) {
233
                if (item[1] === id) {
234
                    this.#requestInterceptorsMap.delete(item[0]);
235
                    break;
236
                }
237
            }
238
239
            return this.#axiosInstance.interceptors.request.eject(id);
240
        }
241
242
        for (const item of this.#responseInterceptorsMap) {
243
            if (item[1] === id) {
244
                this.#responseInterceptorsMap.delete(item[0]);
245
                break;
246
            }
247
        }
248
249
        this.#axiosInstance.interceptors.response.eject(id);
250
    }
251
252
    /**
253
     * Return the request interceptors map
254
     */
255
    public getRequestInterceptorsMap(): Map<string, number>
256
    {
257
        return this.#requestInterceptorsMap;
258
    }
259
260
    /**
261
     * Return the response interceptors map
262
     */
263
    public getResponseInterceptorsMap(): Map<string, number>
264
    {
265
        return this.#responseInterceptorsMap;
266
    }
267
268
    /**
269
     * Return the Axios instance.
270
     */
271
    public getHttpClient(): AxiosInstance {
272
        return this.#axiosInstance;
273
    }
274
275
    /**
276
     * Return the token service.
277
     */
278
    public getStorageService(): StorageService {
279
        return this.#storageService;
280
    }
281
282
    /**
283
     * Proxy to axios generic request.
284
     * It assure to resolve the Promise with a BEditaClientResponse.
285
     *
286
     * @param config Request configuration
287
     */
288
    public async request(config: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
289
        const reqIntercetorsIds = [], respInterceptorsIds = [];
290
        if (config.requestInterceptors) {
291
            config.requestInterceptors.forEach(interceptorInstance => {
292
                reqIntercetorsIds.push(this.addInterceptor(interceptorInstance));
293
            });
294
295
            delete config.requestInterceptors;
296
        }
297
298
        if (config.responseInterceptors) {
299
            config.responseInterceptors.forEach(interceptorInstance => {
300
                respInterceptorsIds.push(this.addInterceptor(interceptorInstance));
301
            });
302
303
            delete config.responseInterceptors;
304
        }
305
        const response = await this.#axiosInstance.request(config);
306
307
        reqIntercetorsIds.forEach(id => this.removeInterceptor(id, 'request'));
308
        respInterceptorsIds.forEach(id => this.removeInterceptor(id, 'response'));
309
310
        return response as BEditaClientResponse;
311
    }
312
313
    /**
314
     * Send a GET request.
315
     *
316
     * @param url The endpoint URL path to invoke
317
     * @param config Request configuration
318
     */
319
    public get(url: string, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
320
        config = config || {}
321
        config.method = 'get';
322
        config.url = url;
323
324
        return this.request(config);
325
    }
326
327
    /**
328
     * Send a POST request.
329
     *
330
     * @param url The endpoint URL path to invoke
331
     * @param data Payload to send
332
     * @param config Request configuration
333
     */
334
    public post(url: string, data?: any, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
335
        config = config || {}
336
        config.method = 'post';
337
        config.url = url;
338
        config.data = data || null;
339
340
        return this.request(config);
341
    }
342
343
    /**
344
     * Send a PATCH request.
345
     *
346
     * @param url The endpoint URL path to invoke
347
     * @param data Payload to send
348
     * @param config Request configuration
349
     */
350
    public patch(url: string, data?: any, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
351
        config = config || {}
352
        config.method = 'patch';
353
        config.url = url;
354
        config.data = data || null;
355
356
        return this.request(config);
357
    }
358
359
    /**
360
     * Send a DELETE request.
361
     *
362
     * @param url The endpoint URL path to invoke
363
     * @param data Payload to send
364
     * @param config Request configuration
365
     */
366
    public delete(url: string, data?: any, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
367
        config = config || {}
368
        config.method = 'delete';
369
        config.url = url;
370
        config.data = data || null;
371
372
        return this.request(config);
373
    }
374
375
    /**
376
     * Authenticate a user, saving in storage access and refresh token.
377
     *
378
     * @param username The username
379
     * @param password The password
380
     */
381
    public async authenticate(username: string, password: string): Promise<BEditaClientResponse<any>> {
382
        if (this.getConfig('apiKey')) {
383
            this.#storageService.clearTokens();
384
        }
385
        this.#storageService.remove('user');
386
        const data: AuthData = { username, password, grant_type: GrantType.Password };
387
388
        return await this.auth(data);
389
    }
390
391
    /**
392
     * Execute an auth request.
393
     *
394
     * @param data The auth data
395
     * @param config Additional request configuration
396
     */
397
    protected async auth(data: AuthData, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
398
        const response = await this.post('/auth', data, config);
399
        const tokens = response.data && response.data.meta || {};
400
        if (!tokens.jwt || !tokens.renew) {
401
            return Promise.reject('Something was wrong with response data.');
402
        }
403
        this.#storageService.accessToken = tokens.jwt;
404
        this.#storageService.refreshToken = tokens.renew;
405
406
        return response;
407
    }
408
409
    /**
410
     * Client credentials auth.
411
     */
412
    public async clientCredentials(): Promise<BEditaClientResponse<any>> {
413
        const data: AuthData = {
414
            client_id: this.getConfig('clientId'),
415
            client_secret: this.getConfig('clientSecret'),
416
            grant_type: GrantType.ClientCredentials,
417
        };
418
419
        return await this.auth(data);
420
    }
421
422
    /**
423
     * Get the authenticated user and store it.
424
     * Format user data using `FormatUserInterceptor`.
425
     */
426
    public async getUserAuth(include?: Array<string>): Promise<BEditaClientResponse<any>> {
427
        const responseInterceptors: Array<ResponseInterceptorInterface> = [new FormatUserInterceptor(this)];
428
        const params: any = {};
429
        if (include && include.length > 0) {
430
            responseInterceptors.unshift(new MapIncludedInterceptor());
431
            params.include = include.join(',');
432
        }
433
434
        const response = await this.get('/auth/user', {
435
            params,
436
            responseInterceptors,
437
        });
438
439
        this.#storageService.set('user', JSON.stringify(response.formattedData));
440
441
        return response;
442
    }
443
444
    /**
445
     * Renew access and refresh tokens.
446
     */
447
    public async renewTokens(): Promise<BEditaClientResponse<any>> {
448
        const refreshToken = this.#storageService.refreshToken;
449
        if (!refreshToken) {
450
            return Promise.reject('Missing refresh token.');
451
        }
452
453
        const config = {
454
            headers: {
455
                Authorization: `Bearer ${refreshToken}`,
456
            },
457
        };
458
459
        try {
460
            return await this.auth({ grant_type: GrantType.RefreshToken }, config);
461
        } catch (error) {
462
            this.#storageService.clearTokens().remove('user');
463
            throw error;
464
        }
465
    }
466
467
    /**
468
     * Save a resource.
469
     * If data contains `id` then it create new one resource
470
     * else it update existing resource.
471
     *
472
     * @param type The resource type
473
     * @param data The data to save
474
     */
475
    public async save(type: string, data: {[s: string]: any}): Promise<BEditaClientResponse> {
476
        if (!type) {
477
            throw new Error('Missing required type');
478
        }
479
480
        const body: {data: JsonApiResourceObject} = { data: {type} };
481
        const id: string|null = data?.id;
482
        if (id) {
483
            body.data.id = id;
484
        }
485
        delete data.id;
486
        body.data.attributes = data;
487
488
        if (id) {
489
            return await this.patch(`${type}/${id}`, body);
490
        }
491
492
        return await this.post(`${type}`, body);
493
    }
494
}
495